# Scanner v2 - Entwurf, Entscheidungen und Stolperfallen

Dieses Dokument hält die **Überlegungen** hinter dem neuen Scanner-Dialog fest:
warum etwas so gebaut ist, welche Alternativen wir verworfen haben und welche
Bugs uns Zeit gekostet haben. Ziel: dieselben Fehlentscheidungen nicht zweimal
treffen.

> Abgrenzung: [FEATURES.md](FEATURES.md) ist die reine Funktionsliste. Dieses
> Dokument ist das Entscheidungs- und Lessons-Learned-Log.

## Was der Dialog ist

Eine moderne, SwiftUI-basierte Alternative zum alten `IKScannerDeviceView`-Dialog
(`hxScannerViewController`). Lebt **parallel** unter `Libs/Scanner/V2/` und ist
über dieselbe Schnittstelle (`hxScannerDelegate`, drop-in Window-Controller)
einhängbar. Auf macOS 14+ ist er der Standard; der Alt-Dialog bleibt als Opt-out
(`UseLegacyScannerDialog`).

### Dateien

```text
V2/
  Engine/
    ScanModels.swift       Wertetypen: Capabilities, Settings, Page, Error
    ScanDevice.swift       async-Protokoll, das die UI ansteuert
    ICAScanDevice.swift    ICScannerDevice direkt, besitzt die Session
  UI/
    ScannerViewModel.swift Brücke Engine <-> SwiftUI, nutzt hxScannerManager
    ScannerRootView.swift  Geräte-Dropdown + Kompakt-/Detail-Ansicht + Aktionen
    ScanPreviewView.swift  Overview-Bild + Auswahlrechteck
  hxScannerV2WindowController.swift  drop-in Window-Controller
```

## Kernentscheidungen (mit Begründung)

### 1. Nur `ICScannerDevice`, kein `IKScannerDeviceView`
- **Grund:** Ein großer Teil der Fragilität des Alt-Dialogs entstand aus dem
  *Konflikt* zwischen eigenem Code und `IKScannerDeviceView`, die beide die ICA-
  Session verwalten wollten (die `-21345`-Retry-Kaskaden im Alt-Code). Indem wir
  `requestOpenSession`/`requestCloseSession` selbst besitzen, fällt diese ganze
  Bug-Klasse konstruktionsbedingt weg.
- **Verworfen:** Eigener ESCL/AirScan-HTTP-Client (z. B. SwiftESCL). Unnötig,
  weil Apples ICA-"Apple-Treiber" eSCL intern spricht - Netzwerk-Scanner tauchen
  automatisch als `remote` `ICScannerDevice` auf. Ein Backend deckt USB **und**
  Netzwerk ab.

### 2. File-based Transfer statt memory-based (Band-Assemblierung)
- **Grund:** Bei `transferMode = .fileBased` schreibt ImageCaptureCore eine
  fertige Datei (`didScanToURL:`), die wir nur einlesen. Kein manuelles
  Zusammensetzen von `ICScannerBandData`.
- **Verworfen:** `.memoryBased` / `didScanToBandData:`. Mehr Code, mehr Fehler-
  quellen, kein realer Vorteil für unseren Anwendungsfall.
- **Wenn doch nötig:** Nur wenn ein Gerät mit file-based partout nicht
  funktioniert. Vorher nicht.

### 3. SwiftUI, gehostet in `NSWindowController`, macOS 14+
- **Grund:** Neue UI in SwiftUI gemäß Projektrichtung. Window-Controller-Hülle,
  damit die Aufrufschnittstelle identisch zum Alt-Dialog bleibt.
- **Zwang, nicht Kür:** Die UI nutzt 14er-APIs (`ContentUnavailableView`, die
  zweiparametrige `.onChange`), und `Libs/Scanner` kompiliert auch in **PDFify**,
  dessen Deployment-Target noch macOS 10.14 ist. Ohne `@available(macOS 14, *)`
  an allen V2-Typen würde PDFify nicht bauen. Deshalb ist das gesamte Feature
  einheitlich auf 14+ gegated (auch die Engine, obwohl sie für sich genommen
  älter laufen würde).

### 3b. Kompakt-/Detail-Modus, Fenstergröße im Controller
- **Grund:** Zwei Nutzungsmodi - Ersteinrichtung (alle Regler, Vorschau,
  Zuschnitt) vs. wiederkehrendes Scannen (nur Gerät, Ziel, Scan). Der Dialog
  startet kompakt; "Details" blendet Vorschau und Parameter ein. Das hält den
  Normalfall ruhig, ohne Funktion zu verstecken (Progressive Disclosure).
- **Sidebar entfernt:** Statt `NavigationSplitView` ein Geräte-Dropdown oben. Bei
  einem Gerät verschenkt eine Seitenleiste nur Platz; das Dropdown zeigt das
  aktive Gerät trotzdem eindeutig an.
- **Resizing gehört dem Controller, nicht SwiftUI:** `detailsExpanded` (im Model,
  sticky) wird per Callback an `hxScannerV2WindowController` gemeldet, der die
  `NSWindow` per `setFrame` größer/kleiner zieht. SwiftUIs `.frame(min:)` allein
  kann ein bereits größeres Fenster nicht verkleinern und nicht "zurück-snappen".
- **Position UND Größe pro Modus merken:** Für Kompakt und Detail wird je das
  letzte volle Fenster-Frame gesichert (`ScannerWorkflowPrefs.compactFrame` /
  `expandedFrame`). Nur die Größe zu merken reicht nicht - sonst springt das
  Fenster beim Umschalten sichtbar herum. Beim Wechsel wird das Frame des
  *verlassenen* Modus gespeichert und das des Zielmodus wiederhergestellt.
- **Stolperfallen dabei:**
  - **Frame des verlassenen Modus sichern - nicht des Zielmodus:** In
    `applyWindowMode` wird *vor* dem Umsetzen von `isExpanded` gespeichert, sonst
    landet das Frame im falschen Slot.
  - **Erstes `applyWindowMode` nicht persistieren:** `didApplyInitialMode` verhindert,
    dass die Wegwerf-Initialgeometrie als Frame gespeichert wird.
  - **Kein remembered Frame? Top-Left fix halten:** Ohne gespeichertes Frame
    (`frameKeepingTopLeft`): `setFrame` rechnet in Bottom-Left-Koordinaten, also
    `origin.y = oldTop - newHeight`, damit die Titelleiste stehen bleibt.
  - **Auf den Bildschirm klammern:** Ein gemerktes Frame wird via `constrained(_:in:)`
    in die `visibleFrame` gezogen, falls sich die Monitor-Konfiguration geändert hat.
  - **Kompakt = fester Panel:** Für den Kompaktmodus klemmt `windowWillResize` die
    Größe auf genau die Kompaktgröße (Detail lässt Wachstum zu, klemmt nur das
    Minimum). `contentMinSize == contentMaxSize` ist zusätzlich gesetzt.
  - **Mindestgröße über `windowWillResize` erzwingen - nicht nur `contentMinSize`:**
    Mit einer `NSHostingView` als Content-View war `contentMinSize` allein
    unzuverlässig (die Hosting-View beeinflusst die Fenstergrenzen), das Fenster
    ließ sich unter die Mindestgröße ziehen. Verlässlich ist das
    `windowWillResize(_:to:)`-Delegate, das die vorgeschlagene Größe pro Modus
    direkt klemmt. `contentMinSize` und `NSHostingView.sizingOptions = []` sind als
    Gürtel-und-Hosenträger zusätzlich gesetzt.
  - **Initiale Größe ohne Animation:** Beim ersten `showWindow` ist das Fenster
    noch nicht sichtbar - `animate` wird an `window.isVisible` gekoppelt, sonst
    flackert der erste Aufbau. Und da der `didSet` bei gleichbleibendem Wert nicht
    feuert, ruft der Controller `applyWindowMode` nach `start()` zusätzlich
    explizit auf.
  - **Snap-back-Frame auch beim Schließen sichern** (`windowWillClose`), sonst geht
    eine zuletzt geänderte Größe/Position verloren.
- **Vorschau nur auf Anforderung:** Ein Overview wird ausschließlich über den
  "Vorschau"-Knopf (`runOverview()`) ausgelöst - nicht automatisch beim Aufklappen
  oder Verbinden. Wer schnell scannen will, soll nicht auf einen ungefragten
  Vorschau-Scan warten.

### 4. Discovery über `hxScannerManager` wiederverwenden
- **Grund:** Der bestehende `ICDeviceBrowser`-Wrapper ist stabil und lebt über
  die App-Laufzeit. Nicht neu erfinden.
- **Achtung:** Seine Callbacks (`onDeviceAdded`/`onDeviceRemoved`) sind
  Single-Slot. Funktioniert, weil immer nur ein Dialog gleichzeitig eingehängt
  ist. Bei echter Parallelität müsste man das umbauen.

### 5. Output-Vertrag: Batch + Ziel, mit Fallback auf `handleScanData(_:)`
- **Grund:** Ein Scan-Stapel braucht Seiten-Gruppierung und Absicht, die eine
  Einzel-PNG nicht trägt. Der optionale Delegate-Callback
  `handleScannedPages(_:destination:)` liefert den ganzen Stapel plus
  `hxScanDestination`. Hosts, die ihn nicht implementieren, bekommen weiter eine
  PNG pro Seite über das alte `handleScanData(_:)` - der Window-Controller fällt
  automatisch darauf zurück. So bleibt der Alt-Dialog und jeder nicht migrierte
  Host unberührt.
- **Nur zwei Ziele, gesteuert über eine Checkbox.** `hxScanDestination` kennt
  `newDocument` und `appendToCurrent`. Im ViewModel ist das nur die Bool-Property
  `appendToCurrent`; `destination` ist daraus abgeleitet. **Default nicht fix,
  sondern host-abgeleitet:** `start()` setzt `appendToCurrent = canAppendToCurrent
  Document()`. Ein fixes `false` öffnete auch dann ein neues Dokument, wenn man aus
  einem offenen Dokument heraus scannt (das gerade sichtbare Fenster blieb leer) -
  unlogisch. Weiterhin **nicht persistiert** (kein Stale-Doc über Sitzungen), aber
  jede Sitzung frisch aus dem Host-Zustand geseedet. Ein
  früher diskutiertes "ein Dokument pro Seite" wurde bewusst gestrichen: Es war ein
  zweiter, unabhängiger Schalter (anhängen vs. neu **und** eine Datei vs. pro
  Seite), der die UI verkomplizierte; der Nutzen (Einzugs-Stapel in Einzel-Belege
  splitten) wog das nicht auf.
- **Sofortige Übergabe, kein Sammeln:** Jeder Scan-Durchlauf wird unmittelbar über
  `onScanPages(_:_:)` geliefert (einmal pro Auslösung) - es gibt keinen Pending-
  Puffer, keinen `finish()`-Schritt und keine Thumbnails mehr. Die Gruppierung
  liegt allein beim Host: bei `newDocument` bildet er aus dem Durchlauf ein neues
  Dokument (Einzugs-Stapel = ein mehrseitiges Dokument, jeder Flachbett-Druck =
  ein eigenes), bei `appendToCurrent` hängt er den Durchlauf an das aktuelle
  Dokument. Diese Vereinfachung war eine bewusste Entscheidung: die frühere
  Sammel-/Commit-Mechanik (Stapel-Thumbnails, "Fertig" auf Cmd-RETURN) wog die
  zusätzliche UI-Komplexität nicht auf.
- **`scannerCanAppendToCurrentDocument()`:** Optionaler Delegate, der meldet, ob
  es ein Zieldokument gibt (PDFify: aktives/letztes Dokument; Receipts: ausgewählter
  Beleg). Ist das nicht der Fall, ist die Anhängen-Checkbox ausgegraut statt zu
  verschwinden - das hält das UI stabil. Der Status wird beim Rendern ausgewertet
  und nicht live nachgezogen, wenn sich die Host-Auswahl bei offenem Scanner ändert.

### 6. async/await über `CheckedContinuation`
- **Grund:** Die Delegate-Callbacks von ImageCaptureCore werden in ein
  modernes async-API gekapselt (`open`, `select`, `overviewScan`, `scan`).
  Lesbarer Ablauf statt Callback-Verschachtelung.
- **Regel:** Pro Operation höchstens eine offene Continuation. Beim Auflösen
  immer auf `nil` setzen, damit ein evtl. nachfolgender Completion-Callback ein
  harmloser No-op ist.

### 7. Vorschau nur bei Flachbett
- **Grund:** `requestOverviewScan` existiert sinnvoll nur für Flachbett. Einzug
  und Netzwerk scannen direkt. Die UI degradiert über `supportsOverview`.

### 8. Scan-Bereich: volle Fläche oder gültige, nicht-leere Selektion - nichts dazwischen
- **Grund:** Siehe Stolperfalle "0x0-Scan" unten. Eine eigene `scanArea` wird
  gesetzt, sobald eine nicht-leere Auswahl existiert (Papierformat-Preset,
  Zahlenfeld oder Drag-Selektion). Eine bewusste Größenwahl gilt **auch ohne
  vorherige Vorschau** - sie ist eine konkrete, gültige Fläche. Nur eine fehlende
  oder leere/winzige Auswahl fällt auf die Geräte-Default-Fläche (ganzes Bett)
  zurück. Die 0x0-Absicherung liegt in der Engine (siehe E), nicht in einer
  Vorschau-Pflicht im Modell.

### 9. Schwarz-Weiß per Postprocessing, kein Hardware-1-Bit
- **Grund:** Viele Treiber (mehrere Epson-Modelle, fast alle eSCL/AirScan-
  Netzwerkscanner) lehnen `pixelDataType = .BW` / `bitDepth = .depth1Bit` ab und
  brechen den Scan mit `-9933` ab. 8-Bit-Graustufen und Farbe werden dagegen
  praktisch überall unterstützt.
- **Lösung:** `apply()` fordert nie mehr 1-Bit an. Der Modus "Schwarz-Weiß"
  scannt 8-Bit-Grau und wird in `ScannerViewModel.binarized` per Software
  schwellwert-binarisiert. Der Schwellwert kommt aus **Otsu** (`otsuThreshold`,
  Maximierung der Zwischenklassen-Varianz über das Histogramm), **nicht** mehr fix
  bei 128: Ein fester Cut kippte über-/unterbelichtete Seiten komplett schwarz oder
  weiß (genau die Beschwerde "S/W nicht besonders gut, Graustufen gut"). Zusätzlich
  klemmt `supportedBitDepth(_:for:)` die Bittiefe defensiv auf das, was das Gerät
  meldet.
- **Folgeideen:** lokal-adaptiver Schwellwert (Integralbild-Mittelwert / Sauvola)
  gegen *innerhalb einer Seite* ungleiche Ausleuchtung - Otsu ist global und hilft
  dagegen nicht; echtes 1-Bit-PNG statt 8-Bit-Grau mit 0/255.

## Stolperfallen / gelöste Bugs (nicht erneut hineinlaufen)

### A. `-9933` ("inconsistent bit depth") beim Scannen ohne Vorschau
- **Symptom:** Direkt scannen (ohne vorher Vorschau) schlug mit `-9933` fehl;
  nach einer Vorschau ging es.
- **Ursache:** Wir haben `scanArea` aus `unit.physicalSize` gesetzt. Manche
  Flachbettscanner (Canon MG3600) liefern `physicalSize` aber erst **nach** einem
  Overview-Scan korrekt - davor `(0,0)`. Damit setzten wir eine Null-Fläche.
- **Lehre:** `physicalSize` ist vor dem ersten Overview **nicht verlässlich**.
  Niemals blind eine `scanArea` daraus ableiten. Ohne echte Selektion die
  Geräte-Default-Fläche nutzen (gar keine `scanArea` setzen).

### B. Scan hängt, `isBusy` bleibt stehen
- **Ursache:** Der Fehler kam über `device(_:didEncounterError:)`, nicht über
  `scannerDevice(_:didCompleteScanWithError:)`. Wir haben dort nur geloggt - die
  `scanContinuation` wurde nie aufgelöst.
- **Lehre:** Fehler können über den generischen `didEncounterError` kommen.
  Dieser muss laufende `scan`/`overview`-Continuations mit dem Fehler beenden.

### B2. "Abbrechen" wirkt nicht, HUD bleibt stehen
- **Symptom:** Während eines laufenden (langen) Scans auf Abbrechen drücken - nichts
  passiert: das Fortschritts-HUD bleibt, der Dialog wirkt eingefroren.
- **Ursache:** `cancel()` rief nur `scanner.cancelScan()`. ImageCaptureCore liefert
  nach einem Nutzer-Cancel aber oft **kein** `didCompleteScanWithError`, also wurde
  die `scanContinuation` nie aufgelöst -> `await device.scan(...)` hing -> `isBusy`
  blieb `true` -> HUD blieb.
- **Lehre:** `cancel()` löst die offene `scan`/`overview`-Continuation **selbst** mit
  `ScanError.cancelled` auf (analog zu `didEncounterError`). Das Resumen entnilt die
  Continuation, ein evtl. später doch eintreffender Completion-Callback ist ein
  harmloser No-op. `runScan`/`runOverview` fangen `.cancelled` ab, `defer` räumt
  `isBusy`/HUD sofort ab.

### C. Dauerspinner beim zweiten Öffnen
- **Ursache:** `stop()` hat `selectedDeviceID` nicht zurückgesetzt. Beim erneuten
  Öffnen war die Auswahl noch gesetzt -> `.onChange` feuerte nicht -> `connect()`
  lief nie -> `capabilities` blieb `nil` -> Detail hing im `ProgressView`.
- **Lehre:** Beim Schließen den **kompletten** UI-Zustand zurücksetzen
  (`selectedDeviceID`, `capabilities`, `overviewImage`, `selectionRect`,
  `isBusy`, `statusMessage`). Teil-Resets erzeugen genau solche toten Zustände.
- **Verwandt (Auto-Auswahl):** Bei genau einem Gerät wird in `refreshDevices()`
  vorausgewählt. Da `.onChange` einen schon beim ersten Erscheinen gesetzten Wert
  **nicht** meldet, ruft die Auto-Auswahl `selectDevice()` zusätzlich direkt auf.
  Damit das nicht doppelt verbindet, ist `selectDevice()` idempotent
  (`guard id != currentDeviceID`) - Direktaufruf und `.onChange` lösen zusammen
  nur ein `connect()` aus.

### D. "Publishing changes from within view updates is not allowed"
- **Ursache:** Die Geräte-Callbacks veröffentlichten `devices` teils mitten in
  einem SwiftUI-Update.
- **Lehre:** Zustandsänderungen, die als Seiteneffekt von Browser-Callbacks
  kommen, über `DispatchQueue.main.async` auf den nächsten Runloop verschieben
  (so macht es auch der Alt-Code).

### E. 0x0-Scan durch leere oder veraltete Selektion
- **Ursache:** `selectionRect` konnte `nil`, aber auch ein leeres/winziges oder
  veraltetes Rechteck sein und wurde auch ohne sichtbare Vorschau herangezogen.
- **Lehre (Doppelte Absicherung):**
  1. Modell: `effectiveScanArea()` nimmt die Selektion nur bei
     `width/height > 0.01`, sonst volle Fläche. (Früher zusätzlich an eine
     sichtbare Vorschau gekoppelt - das verwarf aber bewusste Papierformat-Presets
     ohne Vorschau, siehe Entscheidung 8. Die Vorschau-Pflicht ist entfallen.)
  2. Engine: `ICAScanDevice.apply` setzt `scanArea` nur bei `physicalSize > 0` und
     verwirft ein berechnet-leeres Rechteck. Das ist die eigentliche 0x0-Absicherung
     und greift auch ohne Vorschau (unzuverlässige `physicalSize` -> volle Fläche).
  3. Frische Vorschau verwirft die alte Selektion.

### F. Dauerspinner nach fehlgeschlagenem Verbinden
- **Symptom:** Nach Auswahl eines Geräts schlug `open()`/`select()` fehl (z. B.
  ICA `-9921`, Gerät belegt/aus/offline). Es erschien nur ein Alert mit der rohen
  System-Meldung; danach drehte der Spinner endlos weiter.
- **Ursache:** Das Detail-Pane zeigt den Spinner, solange
  `selectedDeviceID != nil && capabilities == nil`. Der Fehler-Handler setzte
  `capabilities` nicht und die Auswahl blieb stehen -> toter Zustand (wie C, aber
  ausgelöst durch einen Verbindungsfehler statt durch einen Teil-Reset).
- **Lehre:** Ein Verbindungsfehler braucht einen **eigenen sichtbaren Zustand**.
  `connect()` setzt `connectionError`; das Detail-Pane rendert daraus eine
  `ContentUnavailableView` mit "Erneut versuchen" (`retryConnect()`) statt des
  Spinners. `connectionError` wird bei jedem neuen Verbindungsversuch, in
  `stop()` und beim Wegfallen des Geräts geleert.
- **Verwandt (Klartext):** Die ICA-Codes sind kryptisch. `ScanError.deviceError`
  bildet die häufigen Fälle auf handlungsorientierte Meldungen ab
  (`-9925/-9926` in Benutzung, `-9945/-9950` fehlender Treiber, `-9921/-9927/
  -9923/-9958` Sitzung konnte nicht geöffnet werden); unbekannte Codes fallen auf
  die System-Beschreibung zurück.

### G. Netzwerk-Scanner scheitert beim ersten `requestOpenSession`
- **Symptom:** Canon MG3600 (WLAN) liefert beim Verbinden `-9921` ("Sitzung
  ungültig" / lokalisiert "Öffnen der Sitzung fehlgeschlagen"), direkt davor ein
  `closing ... closed session (-9958)`. Nach mehrfachem manuellem "Erneut
  versuchen" klappt es. In Apples Vorschau/Image Capture klappt es "sofort".
- **Ursache:** Zwei Effekte überlagern sich. (1) Netzwerk-Scanner schlafen im
  WLAN-Energiesparmodus; der erste Kontakt weckt sie erst und scheitert. (2) Wir
  schließen die vorige Sitzung auf demselben `ICScannerDevice` und öffnen sofort
  neu - close und open rennen gegeneinander (`-9958`/`-9921`). Apple wiederholt
  intern, deshalb wirkt es dort nahtlos.
- **Lehre:** `open()` wiederholt transiente Session-Fehler selbsttätig mit
  Backoff (400/900/1800 ms) und wartet dazwischen den **awaitable** Overload von
  `requestCloseSession()` ab (statt sofort neu zu öffnen). Nur transiente Codes
  werden wiederholt; "in Benutzung"/"Treiber fehlt" sofort gemeldet.
- **SDK-Falle:** Im `async`-Kontext wählt Swift die neue
  `requestOpenSession()/requestCloseSession() async throws`-Überladung. Im
  synchronen `close()` greift dagegen die alte void-Variante - derselbe Aufruf
  braucht hier `try await`, dort nicht.

### H. Doppelter `connect()` durch Auto-Auswahl (eigentliche `-9921`-Ursache)
- **Symptom:** `-9921` bei **jedem** Öffnen, direkt nach einem `closing <gerät>`
  - obwohl es noch der erste Verbindungsversuch ist. Eine frühere Version
  (vor der Auto-Auswahl) funktionierte einwandfrei.
- **Ursache:** Die Auto-Auswahl in `refreshDevices()` setzt `selectedDeviceID`
  **und** ruft `selectDevice()` direkt auf. Das Setzen feuert zusätzlich
  SwiftUIs `.onChange` -> `selectDevice()` läuft **zweimal** für eine Auswahl.
  Der Schutz `guard id != currentDeviceID` war wirkungslos, weil `currentDeviceID`
  erst **asynchron in `connect()`** (im `Task`) gesetzt wurde. Also starteten
  zwei `connect()`-Tasks auf demselben Gerät; der zweite rief `current?.close()`
  auf dem Wrapper des ersten auf (`requestCloseSession` auf demselben
  `ICScannerDevice`) und öffnete sofort neu -> Race -> `-9958`/`-9921`.
- **Lehre:** Den Geräte-Anspruch (`currentDeviceID = id`) **synchron in
  `selectDevice()`** setzen, nicht im async `connect()`. Sonst ist jeder
  Idempotenz-`guard` gegen ein asynchron gesetztes Feld nutzlos. Das
  "persist beim Verlassen" wandert dabei mit nach `selectDevice()` (vor dem
  Überschreiben), `connect()` setzt `currentDeviceID` nicht mehr.
- **Hinweis:** Der Auto-Retry aus G heilt das Symptom zusätzlich, aber die
  Race ist hier die *eigentliche* Ursache des "jedes Mal"-Fehlers.

### I. "Aufwärmen ..." bleibt den ganzen Scan über stehen
- **Symptom:** Der Fortschritt zeigte dauerhaft "Aufwärmen", nie "Scannt".
- **Ursache:** Das Gerät sendet `ICScannerStatusWarmingUp`, aber wir setzten den
  Status nie zurück. Beim Flachbett gibt es zwischen Aufwärmen und Fertig kein
  weiteres Live-Signal (der Seitenzähler ist auf den Einzug beschränkt) - also
  blieb der Aufwärm-Hinweis hängen.
- **Lehre (doppelt abgesichert):** (1) `ICScannerStatusWarmUpDone` auswerten und den
  Status auf die Basis-Nachricht ("Scannt"/"Vorschau") zurücksetzen. (2) Da manche
  Treiber (Canon) kein "Done" senden, zusätzlich ein Debounce: Bleiben die
  Aufwärm-Signale ~2 s aus, gilt Aufwärmen als beendet.
- **SDK-Falle:** Apple schreibt die Konstanten inkonsistent - `WarmingUp`, aber
  `WarmUpDone` (ohne "ing"). Beide existieren im Header.

## ImageCaptureCore-Fakten (gegen die SDK-Header verifiziert)

Diese kosten sonst leicht eine Stunde Raterei:

- Die Liste der Einheiten ist **`availableFunctionalUnitTypes`** (`[NSNumber]`
  mit `ICScannerFunctionalUnitType`-Rohwerten), **nicht** `availableFunctionalUnits`.
  Konkrete `ICScannerFunctionalUnit`-Objekte gibt es nur via `selectedFunctionalUnit`.
- ObjC `requestSelectFunctionalUnit:` heißt in Swift **`requestSelect(_:)`**.
- `ICRect`/`ICSize` sind auf macOS per `#define` gleich `NSRect`/`NSSize`
  (= `CGRect`/`CGSize`). Auf iOS wären es `CGRect`/`CGSize`.
- `pixelDataType`: `.BW` / `.gray` / `.RGB`. `bitDepth`: `.depth1Bit` /
  `.depth8Bits` / `.depth16Bits`.
- `overviewImage` liegt auf der **FunctionalUnit**, nicht auf dem Device, und ist
  `CGImage?`.
- Alle `scannerDevice(...)`-Delegate-Methoden sind `@optional`. Aus
  `ICDeviceDelegate` sind **required**: `didRemove(_:)`,
  `device(_:didOpenSessionWithError:)`, `device(_:didCloseSessionWithError:)`.
- `requestSelectFunctionalUnit:` quittiert mit `didSelectFunctionalUnit:error:`,
  `requestOverviewScan` mit `didCompleteOverviewScanWithError:`, `requestScan` mit
  `didCompleteScanWithError:` (plus `didScanToURL:` je Seite).
- **Live-Fortschritt** speist sich nur aus wenigen Signalen: `didScanToURL:` (je
  fertige Seite -> Seitenzähler beim Einzug) und `didReceiveStatusInformation:`
  mit `ICScannerStatusWarmingUp` / `ICScannerStatusWarmUpDone` (Aufwärmen an/aus -
  beachte Apples inkonsistente Schreibweise). Alle werden als `ScanProgress` an
  `ScanDevice.onProgress` weitergereicht. Der mitgelieferte
  `localizedStatusNotificationKey` ist treiberabhängig oft leer -> wir mappen den
  Notification-Code auf eigene Strings. Byte-/Prozent-Fortschritt gibt es im
  `.fileBased`-Modus nicht.
- `ICScannerDevice.delegate` ist historisch `unsafe_unretained` -> beim Schließen
  `delegate = nil` setzen, sonst Crash bei späteren Browser-Events.
- `ICScannerDevice.downloadsDirectory` ist **irreführend benannt**: es ist nur das
  Zielverzeichnis des file-based Transfers, **nicht** `~/Downloads`. Wir zeigen
  damit auf das Sandbox-Temp-Verzeichnis (kein TCC-Prompt, auto-Cleanup). Unsere
  lokale Variable heißt deshalb bewusst `outputDirectory`.
- Die Delegate-Konformität braucht `@preconcurrency`, weil die Klasse
  `@MainActor` ist (so handhabt es auch der restliche Codebase).

## Offene Punkte / noch ungeprüft

- **Hardware:** Koordinaten-Orientierung der Selektions-`scanArea` (oben-links
  vs. unten-links) ist noch nicht an echtem Flachbett verifiziert. Markiert mit
  `TODO(hardware)` in `ICAScanDevice.apply(_:to:)`. Vollformat-Scans sind davon
  nicht betroffen.
- **Lokalisierung:** Der Dialog selbst ist de/en. Weitere Sprachen aus de/en
  ableiten (Receipts: es, fr, it, ja, nl, pt-BR, tr, zh-Hans; PDFify: fr). Beim
  Streichen von "pro Seite" sind die Keys `scanner.v2.destination.newDocument`,
  `.perPage` und `scanner.v2.label.destination` ungenutzt geworden (tote Strings,
  noch nicht aufgeräumt).
- **Anhängen-Status nicht reaktiv:** Wechselt die Host-Auswahl (PDFify-Dokument,
  Receipts-Beleg) bei *offenem* Scanner, wird die Ausgrau-Logik der
  Anhängen-Checkbox nicht live nachgezogen (siehe Entscheidung 5).

Erledigt (waren mal offen): PDFify und Receipts sind voll im `AppDelegate`
verdrahtet (Batch-Lieferung, Anhängen, in Receipts zusätzlich OCR/Erkennung für
angehängte Seiten). **Continuity Camera** bleibt bewusst außen vor - kein
Dialog-Feature; beide Apps unterstützen sie unabhängig über die Responder-Chain
(`NSServicesMenuRequestor`: `validRequestor` + `readSelection(from:)`), die das
System-Menü "Vom iPhone importieren -> Dokumente scannen" einblendet und das
Ergebnis direkt in die normale Import-Pipeline gibt. ImageCaptureCore spielt dort
keine Rolle.

## Abgrenzung der Backends

- **Backend ist ausschließlich `ICScannerDevice`** (USB + Netzwerk via Apple-
  Treiber). Kein eigener ESCL/AirScan-Code, siehe Entscheidung 1.
- **ScanSnap (Fujitsu/PFU) ist kein Backend:** app-getrieben, liefert Scans über
  eigene Software, nicht über ImageCaptureCore. Der Alt-Dialog faktete dafür einen
  "ScanSnap"-Geräteeintrag; v2 tut das nicht. Ist die ScanSnap-Software erkannt,
  zeigt der Dialog stattdessen einen schmalen, wegklickbaren Hinweis-Streifen
  unten, der auf die App und die Hilfeseite zeigt. Erkennung + Start in
  `ScanSnapSupport`, Dismiss-Flag in `ScannerWorkflowPrefs`, Hilfe-URL pro App
  über `hxScannerDelegate.scanSnapHelpURL()`.

## Alt-Dialog

Der alte `IKScannerDeviceView`-Dialog bleibt als Opt-out
(`UseLegacyScannerDialog`) erhalten. Ein zwingendes Abschalten ist nicht geplant -
v2 ist Standard auf macOS 14+, der Alt-Pfad dient als Rückfalloption.

## Referenz-Projekte (gelesen/portiert, keine Abhängigkeit)

- DavidPhillipOster/imageCaptureScanner (Apache 2.0) - `ICScannerDevice`-Mechanik:
  Session, Functional Units, Overview, Scan.
- klep/scanline - kopfloser Scan-Ablauf, Dokumenteneinzug.
- cyanfish/naps2 (GPL, nur Referenz) - Fehlercode-Behandlung / Recovery.
